chore: integrate storageService with tokenListController#7413
Merged
sahar-fehri merged 50 commits intomainfrom Jan 26, 2026
Merged
chore: integrate storageService with tokenListController#7413sahar-fehri merged 50 commits intomainfrom
sahar-fehri merged 50 commits intomainfrom
Conversation
sahar-fehri
commented
Dec 9, 2025
| tokensChainsCache: { | ||
| includeInStateLogs: false, | ||
| persist: true, | ||
| persist: false, // Persisted separately via StorageService |
Contributor
Author
There was a problem hiding this comment.
Making this false to block disk writes
7 tasks
…oading and saving
Prithpal-Sooriya
previously approved these changes
Dec 16, 2025
salimtb
previously approved these changes
Dec 16, 2025
…tchTokenList calls
Contributor
Author
|
@metamaskbot publish-preview |
Contributor
|
Preview builds have been published. See these instructions for more information about preview builds. Expand for full list of packages and versions. |
Prithpal-Sooriya
approved these changes
Jan 26, 2026
4 tasks
github-merge-queue bot
pushed a commit
to MetaMask/metamask-mobile
that referenced
this pull request
Jan 28, 2026
## **Description** Do not merge until this is released MetaMask/core#7413 # Performance Comparison: Per-Chain Token Cache Storage This PR implements per-chain file storage for `tokensChainsCache` in `TokenListController`, replacing the single-file approach. Each chain's token list is now stored in a separate file, reducing write amplification during incremental updates. --- ## 📊 Complete Performance Comparison ### Cold Restart | Metric | This PR | Main Branch | |--------|---------|-------------| | getAllPersistedState | 235ms | 288ms | | TokenListController read | 0.04KB (shell only) | **4,102KB** | | Cache load | 97ms (parallel reads) | **135ms** (single file) | | Total overhead | ~332ms | ~288ms | **Main is ~44ms faster on cold restart** (single file read vs parallel reads + getAllKeys overhead) --- ### Onboarding | Metric | This PR | Main Branch | |--------|---------|-------------| | Total data written | **4,070KB** | **9,472KB** | | Number of writes | 7 (one per chain) | 5 (cumulative rewrites) | | Total write time | ~38ms | ~118ms | **This PR writes 57% less data and is 3x faster** --- ### Add New Chain (Monad) | Metric | This PR | Main Branch | |--------|---------|-------------| | Data written | **33.79KB** | **4,103KB** | | Time | **0.23ms** | **45.34ms** | **This PR is 121x smaller and 197x faster!** --- ## Summary | Category | This PR | Main Branch | Winner | |----------|---------|-------------|--------| | Cold restart | ~332ms | ~288ms | Main (+44ms) | | Onboarding writes | 4,070KB | 9,472KB | **This PR (-57%)** | | Onboarding time | ~38ms | ~118ms | **This PR (3x faster)** | | Add chain writes | 33.79KB | 4,103KB | **This PR (-99%)** | | Add chain time | 0.23ms | 45.34ms | **This PR (197x faster)** | | Write amplification | None | Severe | **This PR** | --- ## 📋 Captured Logs ### This PR - Cold Restart ``` [ControllerStorage PERF] getAllPersistedState started [ControllerStorage PERF] TokenListController - 0.04KB - read: 89.00ms, parse: 0.00ms, total: 89.00ms [ControllerStorage PERF] getAllPersistedState complete - 235.37ms [StorageService PERF] getAllKeys TokenListController - 7 keys found - 277.37ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0xa - 96.19KB - read: 3.12ms, parse: 0.47ms, total: 3.59ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0x1 - 1608.95KB - read: 30.86ms, parse: 10.57ms, total: 41.43ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0x38 - 1288.32KB - read: 48.95ms, parse: 21.65ms, total: 70.60ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0x89 - 324.12KB - read: 72.62ms, parse: 5.21ms, total: 77.83ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0xa4b1 - 222.52KB - read: 77.90ms, parse: 7.06ms, total: 84.96ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0xe708 - 46.92KB - read: 85.16ms, parse: 0.82ms, total: 85.97ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0x2105 - 481.64KB - read: 88.85ms, parse: 8.74ms, total: 97.58ms ``` ### This PR - Onboarding ``` [ControllerStorage PERF] getAllPersistedState complete - 731.91ms [StorageService PERF] getAllKeys TokenListController - 0 keys found - 309.51ms [StorageService PERF] getItem TokenListController:tokensChainsCache - NOT FOUND - 33.51ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0x1 - 1610.14KB - stringify: 8.64ms, write: 8.54ms, total: 17.17ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0xe708 - 46.92KB - stringify: 0.19ms, write: 0.08ms, total: 0.26ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0x2105 - 481.34KB - stringify: 1.50ms, write: 2.45ms, total: 3.96ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0xa4b1 - 222.53KB - stringify: 1.03ms, write: 0.52ms, total: 1.56ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0x38 - 1288.32KB - stringify: 4.74ms, write: 6.49ms, total: 11.23ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0xa - 96.19KB - stringify: 0.31ms, write: 0.52ms, total: 0.83ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0x89 - 324.46KB - stringify: 1.10ms, write: 1.72ms, total: 2.82ms ``` ### This PR - Add New Chain (Monad) ``` [StorageService PERF] setItem TokenListController:tokensChainsCache:0x8f - 33.79KB - stringify: 0.17ms, write: 0.07ms, total: 0.23ms ``` ### Main Branch - Cold Restart ``` [ControllerStorage PERF] getAllPersistedState started [ControllerStorage PERF] TokenListController - 4102.55KB - read: 112.51ms, parse: 22.77ms, total: 135.27ms [ControllerStorage PERF] getAllPersistedState complete - 288.21ms ``` ### Main Branch - Onboarding ``` [ControllerStorage PERF] getAllPersistedState complete - 785.03ms [ControllerStorage PERF] setItem TokenListController - 0.06KB - stringify: 0.00ms, write: 0.02ms, total: 0.02ms [ControllerStorage PERF] setItem TokenListController - 1609.28KB - stringify: 13.41ms, write: 11.58ms, total: 24.99ms [ControllerStorage PERF] setItem TokenListController - 1656.21KB - stringify: 12.85ms, write: 12.20ms, total: 25.04ms [ControllerStorage PERF] setItem TokenListController - 2137.56KB - stringify: 12.47ms, write: 11.40ms, total: 23.87ms [ControllerStorage PERF] setItem TokenListController - 4068.75KB - stringify: 22.00ms, write: 22.62ms, total: 44.62ms ``` ### Main Branch - Add New Chain (Monad) ``` [ControllerStorage PERF] setItem TokenListController - 4102.55KB - stringify: 23.52ms, write: 21.82ms, total: 45.34ms ``` --- ## 🔧 Performance Logging Code (Main Branch) The following code was added to `app/store/persistConfig/index.ts` to capture performance metrics: ### Read Performance Logging (getAllPersistedState) ```typescript async getAllPersistedState(): Promise<Record<string, unknown>> { // eslint-disable-next-line no-console console.warn('[ControllerStorage PERF] getAllPersistedState started'); const totalStart = performance.now(); try { const backgroundState: Record<string, unknown> = {}; await Promise.all( Array.from( new Set( Array.from(BACKGROUND_STATE_CHANGE_EVENT_NAMES).map( (eventName) => eventName.split(':')[0], ), ), ).map(async (controllerName) => { const key = `persist:${controllerName}`; const startTime = performance.now(); try { const data = await FilesystemStorage.getItem(key); if (data) { const parseStart = performance.now(); const parsedData = JSON.parse(data); const parseDuration = performance.now() - parseStart; const totalDuration = performance.now() - startTime; // Log performance for TokenListController specifically if (controllerName === 'TokenListController') { const sizeKB = (data.length / 1024).toFixed(2); // eslint-disable-next-line no-console console.warn( `[ControllerStorage PERF] ${controllerName} - ${sizeKB}KB - ` + `read: ${(totalDuration - parseDuration).toFixed(2)}ms, ` + `parse: ${parseDuration.toFixed(2)}ms, ` + `total: ${totalDuration.toFixed(2)}ms`, ); } // ... rest of the function } } catch (error) { // error handling } }), ); const totalDuration = performance.now() - totalStart; // eslint-disable-next-line no-console console.warn( `[ControllerStorage PERF] getAllPersistedState complete - ${totalDuration.toFixed(2)}ms`, ); return { backgroundState }; } catch (error) { // error handling } } ``` ### Write Performance Logging (createPersistController) ```typescript export const createPersistController = (debounceMs: number = 200) => debounce(async (filteredState: unknown, controllerName: string) => { const startTime = performance.now(); try { const stringifyStart = performance.now(); const serialized = JSON.stringify(filteredState); const stringifyDuration = performance.now() - stringifyStart; await ControllerStorage.setItem(`persist:${controllerName}`, serialized); const totalDuration = performance.now() - startTime; if (controllerName === 'TokenListController') { const sizeKB = (serialized.length / 1024).toFixed(2); // eslint-disable-next-line no-console console.warn( `[ControllerStorage PERF] setItem ${controllerName} - ${sizeKB}KB - ` + `stringify: ${stringifyDuration.toFixed(2)}ms, ` + `write: ${(totalDuration - stringifyDuration).toFixed(2)}ms, ` + `total: ${totalDuration.toFixed(2)}ms`, ); } Logger.log(`${controllerName} state persisted successfully`); } catch (error) { // error handling } }, debounceMs); ``` --- ## 🔧 Performance Logging Code (This PR) The following code was added to `app/core/Engine/controllers/storage-service-init.ts` to capture performance metrics for the per-chain storage: ### getItem - Read Performance Logging ```typescript async getItem(namespace: string, key: string): Promise<StorageGetResult> { // eslint-disable-next-line no-console console.warn(`[StorageService DEBUG] getItem called: ${namespace}:${key}`); const startTime = performance.now(); try { const fullKey = `${STORAGE_KEY_PREFIX}${namespace}:${key}`; const serialized = await FilesystemStorage.getItem(fullKey); // Key not found - return empty object if (serialized === undefined || serialized === null) { const duration = performance.now() - startTime; if ( key.includes('token') || key.includes('Token') || namespace.includes('Token') ) { // eslint-disable-next-line no-console console.warn( `[StorageService PERF] getItem ${namespace}:${key} - NOT FOUND - ${duration.toFixed(2)}ms`, ); } return {}; } const parseStart = performance.now(); const result = JSON.parse(serialized) as Json; const parseDuration = performance.now() - parseStart; const totalDuration = performance.now() - startTime; if ( key.includes('token') || key.includes('Token') || namespace.includes('Token') ) { const sizeKB = (serialized.length / 1024).toFixed(2); // eslint-disable-next-line no-console console.warn( `[StorageService PERF] getItem ${namespace}:${key} - ${sizeKB}KB - ` + `read: ${(totalDuration - parseDuration).toFixed(2)}ms, ` + `parse: ${parseDuration.toFixed(2)}ms, ` + `total: ${totalDuration.toFixed(2)}ms`, ); } return { result }; } catch (error) { // error handling } } ``` ### setItem - Write Performance Logging ```typescript async setItem(namespace: string, key: string, value: Json): Promise<void> { // eslint-disable-next-line no-console console.warn(`[StorageService DEBUG] setItem called: ${namespace}:${key}`); const startTime = performance.now(); try { const fullKey = `${STORAGE_KEY_PREFIX}${namespace}:${key}`; const stringifyStart = performance.now(); const serialized = JSON.stringify(value); const stringifyDuration = performance.now() - stringifyStart; await FilesystemStorage.setItem(fullKey, serialized, Device.isIos()); const totalDuration = performance.now() - startTime; if ( key.includes('token') || key.includes('Token') || namespace.includes('Token') ) { const sizeKB = (serialized.length / 1024).toFixed(2); // eslint-disable-next-line no-console console.warn( `[StorageService PERF] setItem ${namespace}:${key} - ${sizeKB}KB - ` + `stringify: ${stringifyDuration.toFixed(2)}ms, ` + `write: ${(totalDuration - stringifyDuration).toFixed(2)}ms, ` + `total: ${totalDuration.toFixed(2)}ms`, ); } } catch (error) { // error handling } } ``` ### getAllKeys - Key Enumeration Logging ```typescript async getAllKeys(namespace: string): Promise<string[]> { // eslint-disable-next-line no-console console.warn(`[StorageService DEBUG] getAllKeys called: ${namespace}`); const startTime = performance.now(); try { const allKeys = await FilesystemStorage.getAllKeys(); if (!allKeys) { const duration = performance.now() - startTime; if (namespace.includes('Token')) { // eslint-disable-next-line no-console console.warn( `[StorageService PERF] getAllKeys ${namespace} - 0 keys - ${duration.toFixed(2)}ms`, ); } return []; } const prefix = `${STORAGE_KEY_PREFIX}${namespace}:`; const filteredKeys = allKeys .filter((key) => key.startsWith(prefix)) .map((key) => key.slice(prefix.length)); const duration = performance.now() - startTime; if (namespace.includes('Token')) { // eslint-disable-next-line no-console console.warn( `[StorageService PERF] getAllKeys ${namespace} - ${filteredKeys.length} keys found - ${duration.toFixed(2)}ms`, ); } return filteredKeys; } catch (error) { // error handling } } ``` ## **Changelog** CHANGELOG entry: integrates per chain file save for tokenListController. ## **Related issues** Related: MetaMask/core#7413 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces per-chain StorageService-backed persistence for `TokenListController` and migrates existing cache data. > > - Adds migration `114` to move `TokenListController.tokensChainsCache` from Redux state to per-chain filesystem keys (`storageService:TokenListController:tokensChainsCache:{chainId}`), avoids overwrites, handles errors, and clears in-state cache; includes comprehensive tests > - Expands `TokenListController` messenger to allow `StorageService:getAllKeys|getItem|setItem|removeItem` > - Updates `token-list-controller-init` to pass persisted state, subscribe to network changes, and call `controller.initialize()`; adds tests mocking controller and verifying initialize > - Bumps `@metamask/assets-controllers` to `^98.0.0` and registers migration in `migrations/index.ts` > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 385b6a3. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
github-merge-queue bot
pushed a commit
to MetaMask/metamask-extension
that referenced
this pull request
Jan 29, 2026
Do not merge before this gets in MetaMask/core#7413 ## **Description** # Performance Comparison: Per-Chain Token Cache Storage This PR implements per-chain file storage for `tokensChainsCache` in `TokenListController`, replacing the single-file approach. Each chain's token list is now stored in a separate file via StorageService, reducing write amplification during incremental updates. --- ## 📊 Summary | Category | This PR | Main Branch | Improvement | |----------|---------|-------------|-------------| | Cold restart (getAllPersistedState) | **216.20ms** | **374.30ms** | **42% faster** | | Cold restart (cache load) | **~310ms** (parallel) | Included in state | ----- | | Onboarding - Token cache | **4.40MB** (StorageService) | **4.40MB** (in state) | Stored separately | | Onboarding - Background saves | **~23MB** each | **~28MB** each | **~5MB less per save** | | Onboarding - Token cache in saves | ❌ No | ✅ Yes | **Eliminated** | | **Add Monad** | **39KB** (new chain only) | **~4.4MB** (full cache rewritten) | **Only new chain** | | **Add Avalanche** | **157KB** (new chain only) | **~4.6MB** (full cache rewritten) | **Only new chain** | | **Background saves (idle)** | **~23MB** | **~28MB** | **~18% smaller** | | **TokenListController in state** | **0.04KB** | **4,601KB** | **Cache moved out** | **Key Results**: 1. **Adding a single chain on main branch** triggers a state save that rewrites all TokenListController cache (~4.6MB) plus all other controllers (~23MB) = ~28MB total. 2. **This PR writes ONLY the new chain** (e.g., 39KB for Monad) to StorageService. The full token cache is NOT rewritten. 3. **Background saves are ~5MB smaller** (~23MB vs ~28MB) because the token cache is stored separately. --- ## 🧪 Test Scenarios ### Scenario 1: Cold Restart (Existing User) **Setup**: Extension with 10 networks cached (8 popular + Monad + Avalanche), then browser restart. #### This PR **Controller Storage:** ``` [ControllerStorage PERF] getAllPersistedState complete - 216.20ms ``` **StorageService - getAllKeys:** ``` [StorageService PERF] getAllKeys TokenListController - 10 keys found - 277.60ms ``` **StorageService - Per-chain getItem (parallel reads):** | Chain | Size | Read Time | |-------|------|-----------| | 0x1 (Ethereum) | 2022.39KB | 80.40ms | | 0x38 (BSC) | 1045.19KB | 125.90ms | | 0x2105 (Base) | 436.73KB | 97.20ms | | 0x89 (Polygon) | 388.91KB | 128.30ms | | 0xa4b1 (Arbitrum) | 345.79KB | 159.50ms | | 0xa86a (Avalanche) | 156.90KB | 161.60ms | | 0xa (Optimism) | 125.76KB | 132.20ms | | 0xe708 (Linea) | 40.13KB | 163.20ms | | 0x8f (Monad) | 39.15KB | 131.80ms | | 0xaa36a7 (Sepolia) | 0.04KB | 162.70ms | **Total cache size: ~4.60MB across 10 chains** #### Main Branch **Cold restart with 10 networks cached (8 popular + Monad + Avalanche):** ``` [ControllerStorage PERF] getAllPersistedState started [ControllerStorage PERF] getAllPersistedState complete - 374.30ms [ControllerStorage PERF] TokenListController read - 4601.13KB - 374.30ms ``` #### Comparison | Metric | This PR | Main Branch | Improvement | |--------|---------|-------------|-------------| | **getAllPersistedState** | **216.20ms** | **374.30ms** | **42% faster** | | TokenListController in state | **0.04KB** | **4,601KB** | Moved to StorageService | | StorageService cache load | **~163ms** (10 chains parallel) | N/A | Separate loading | | Total chains | 10 | 10 | Same | | Total state size | ~23MB | ~28MB | **~18% smaller** | **Key insight**: The main state loads **158ms faster** on this PR because it's ~5MB smaller. The token cache is loaded separately via StorageService in parallel during controller initialization. --- ### Scenario 2: Fresh Onboarding **Setup**: Fresh wallet creation, enable all popular networks, wait for token lists to fetch. #### This PR **Initial state (fresh install):** ``` [ControllerStorage PERF] getAllPersistedState complete - 13.70ms [StorageService PERF] getAllKeys TokenListController - 0 keys found - 23.50ms ``` **Per-chain writes as networks are enabled:** | Chain | Size | Stringify | Write | Total | |-------|------|-----------|-------|-------| | 0xaa36a7 (Sepolia) | 0.04KB | 0.00ms | 12.00ms | 12.00ms | | 0xe708 (Linea) | 40.13KB | 0.00ms | 81.70ms | 81.70ms | | 0xa (Optimism) | 125.76KB | 0.20ms | 81.20ms | 81.40ms | | 0x2105 (Base) | 436.73KB | 0.80ms | 79.10ms | 79.90ms | | 0x89 (Polygon) | 388.91KB | 0.80ms | 72.80ms | 73.60ms | | 0xa4b1 (Arbitrum) | 345.79KB | 0.80ms | 67.10ms | 67.90ms | | 0x1 (Ethereum) | 2022.39KB | 4.20ms | 68.40ms | 72.60ms | | 0x38 (BSC) | 1045.19KB | 1.90ms | 37.80ms | 39.70ms | **Summary:** - **Total data written**: ~4.40MB (8 individual writes) - **Number of writes**: 8 (one per chain) - **Total write time**: ~509ms (sum of individual writes) #### Main Branch **Initial state (fresh install):** ``` [ControllerStorage PERF] getAllPersistedState complete - 14.60ms [ControllerStorage PERF] set() - TokenListController size: 0.06KB [ControllerStorage PERF] set() complete - 9952.04KB - 129.10ms ``` **After clicking "All Popular Networks":** ``` [ControllerStorage PERF] set() - TokenListController size: 4405.07KB [ControllerStorage PERF] set() complete - 27830.24KB - 526.70ms ``` **Summary:** - **TokenListController size**: 4,405KB (~4.4MB) - cached in controller state - **Total state written**: 27,830KB (~27.2MB) - entire MetaMask state - **Write time**: 526.70ms #### Comparison | Metric | This PR | Main Branch | Difference | |--------|---------|-------------|------------| | Token cache data | **4.40MB** | **4.40MB** | Same amount | | Token cache location | **StorageService** (separate) | **Main state** | Separated | | Background save size | **~23MB** | **~28MB** | **~5MB smaller** | | Token cache in every save | ❌ **No** | ✅ **Yes** | **Eliminated** | **Key insight**: Both branches have continuous background saves. On main branch, every save includes the ~4.4MB token cache. On this PR, the token cache is stored separately via StorageService, making each background save ~5MB smaller. --- ### Scenario 3: Add New Chain **Setup**: Existing wallet with cached networks, add a new network. #### This PR **Avalanche (0xa86a)**: ``` [StorageService PERF] setItem TokenListController:tokensChainsCache:0xa86a - 156.90KB - stringify: 0.80ms, write: 137.20ms, total: 138.00ms ``` **Monad (0x8f)**: ``` [StorageService PERF] setItem TokenListController:tokensChainsCache:0x8f - 39.15KB - stringify: 0.00ms, write: 2.40ms, total: 2.40ms ``` **zkSync Era (0x144)**: ``` [StorageService PERF] setItem TokenListController:tokensChainsCache:0x144 - 12.98KB - stringify: 0.00ms, write: 0.90ms, total: 0.90ms ``` **Polygon (0x89)**: ``` [StorageService PERF] setItem TokenListController:tokensChainsCache:0x89 - 388.91KB - stringify: 0.90ms, write: 15.40ms, total: 16.30ms ``` #### Main Branch **Adding Monad (0x8f) to existing cache:** ``` [ControllerStorage PERF] set() - TokenListController size: 4444.22KB [ControllerStorage PERF] set() complete - 27877.62KB - 569.00ms ``` **Adding Avalanche (0xa86a) to existing cache:** ``` [ControllerStorage PERF] set() - TokenListController size: 4601.13KB [ControllerStorage PERF] set() complete - 28036.55KB - 559.30ms ``` **Note**: Each chain addition triggers a full state save that includes ALL TokenListController cache (~4.6MB) plus ALL other controllers (~23MB) = ~28MB total. #### Comparison **Token cache write for new chain:** | Chain | This PR | Main Branch | Difference | |-------|---------|-------------|------------| | **Monad (39KB)** | 39KB to StorageService | Full ~4.4MB cache rewritten | **Only new chain written** | | **Avalanche (157KB)** | 157KB to StorageService | Full ~4.6MB cache rewritten | **Only new chain written** | **Total state save triggered:** | Metric | This PR | Main Branch | Difference | |--------|---------|-------------|------------| | State size | ~23MB (no cache) | ~28MB (with cache) | **~5MB smaller** | | Token cache included | ❌ No | ✅ Yes | **Separated** | | New chain write | **39-157KB** (separate file) | Included in 28MB | **Isolated** | --- ## 📋 Raw Logs ### This PR - Cold Restart ``` [ControllerStorage PERF] getAllPersistedState started [ControllerStorage PERF] getAllPersistedState complete - 216.20ms [StorageService PERF] getAllKeys TokenListController - 10 keys found - 277.60ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0x1 - 2022.39KB - read: 80.40ms, total: 80.40ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0x2105 - 436.73KB - read: 97.20ms, total: 97.20ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0x38 - 1045.19KB - read: 125.80ms, total: 125.90ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0x89 - 388.91KB - read: 128.30ms, total: 128.30ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0x8f - 39.15KB - read: 131.80ms, total: 131.80ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0xa - 125.76KB - read: 132.20ms, total: 132.20ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0xa4b1 - 345.79KB - read: 159.50ms, total: 159.50ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0xa86a - 156.90KB - read: 161.60ms, total: 161.60ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0xaa36a7 - 0.04KB - read: 162.70ms, total: 162.70ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0xe708 - 40.13KB - read: 163.20ms, total: 163.20ms ``` ### This PR - Onboarding ``` [ControllerStorage PERF] getAllPersistedState started [ControllerStorage PERF] getAllPersistedState complete - 13.70ms [StorageService PERF] getAllKeys TokenListController - 0 keys found - 23.50ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0xaa36a7 - 0.04KB - stringify: 0.00ms, write: 12.00ms, total: 12.00ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0xe708 - 40.13KB - stringify: 0.00ms, write: 81.70ms, total: 81.70ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0xa - 125.76KB - stringify: 0.20ms, write: 81.20ms, total: 81.40ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0x2105 - 436.73KB - stringify: 0.80ms, write: 79.10ms, total: 79.90ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0x89 - 388.91KB - stringify: 0.80ms, write: 72.80ms, total: 73.60ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0xa4b1 - 345.79KB - stringify: 0.80ms, write: 67.10ms, total: 67.90ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0x1 - 2022.39KB - stringify: 4.20ms, write: 68.40ms, total: 72.60ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0x38 - 1045.19KB - stringify: 1.90ms, write: 37.80ms, total: 39.70ms ``` ### This PR - Add New Chain ``` # Adding Monad (39KB) [StorageService PERF] setItem TokenListController:tokensChainsCache:0x8f - 39.15KB - stringify: 0.10ms, write: 1.60ms, total: 1.70ms [ControllerStorage PERF] set() - TokenListController size: 0.04KB [ControllerStorage PERF] set() complete - 23433.75KB - 418.90ms # Adding Avalanche (157KB) [StorageService PERF] setItem TokenListController:tokensChainsCache:0xa86a - 156.90KB - stringify: 0.50ms, write: 5.30ms, total: 5.80ms [ControllerStorage PERF] set() - TokenListController size: 0.04KB [ControllerStorage PERF] set() complete - 23435.80KB - 416.10ms ``` ### Main Branch - Cold Restart ``` [ControllerStorage PERF] getAllPersistedState started [ControllerStorage PERF] getAllPersistedState complete - 374.30ms [ControllerStorage PERF] TokenListController read - 4601.13KB - 374.30ms [ControllerStorage PERF] set() - TokenListController size: 4601.13KB [ControllerStorage PERF] set() complete - 28033.23KB - 426.60ms [ControllerStorage PERF] set() - TokenListController size: 4601.13KB [ControllerStorage PERF] set() complete - 28033.01KB - 564.00ms ``` ### Main Branch - Onboarding ``` [ControllerStorage PERF] getAllPersistedState started [ControllerStorage PERF] getAllPersistedState complete - 14.60ms [ControllerStorage PERF] set() complete - 0.06KB - 0.30ms [ControllerStorage PERF] set() - TokenListController size: 0.06KB [ControllerStorage PERF] set() complete - 9952.04KB - 129.10ms [ControllerStorage PERF] set() - TokenListController size: 0.06KB [ControllerStorage PERF] set() complete - 9952.30KB - 130.20ms [ControllerStorage PERF] set() - TokenListController size: 0.06KB [ControllerStorage PERF] set() complete - 9952.67KB - 136.10ms [ControllerStorage PERF] set() - TokenListController size: 0.06KB [ControllerStorage PERF] set() complete - 9952.97KB - 136.50ms [ControllerStorage PERF] set() - TokenListController size: 0.06KB [ControllerStorage PERF] set() complete - 9955.72KB - 142.20ms [ControllerStorage PERF] set() - TokenListController size: 4405.07KB [ControllerStorage PERF] set() complete - 27830.24KB - 526.70ms ``` ### Main Branch - Add New Chain (Monad + Avalanche) ``` # Adding Monad (39KB) [ControllerStorage PERF] set() - TokenListController size: 4444.22KB [ControllerStorage PERF] set() complete - 27877.62KB - 569.00ms # Adding Avalanche (157KB) [ControllerStorage PERF] set() - TokenListController size: 4601.13KB [ControllerStorage PERF] set() complete - 28036.55KB - 559.30ms ``` --- ## 🔧 How Performance Was Measured Performance logging was added to: 1. **BrowserStorageAdapter** (`app/scripts/lib/stores/browser-storage-adapter.ts`) - Logs for `getItem`, `setItem`, `getAllKeys` operations - Measures read time, stringify time, write time, and data size 2. **ExtensionStore** (`app/scripts/lib/stores/extension-store.ts`) - Logs for `getAllPersistedState` and controller state writes - Measures TokenListController-specific read/write performance To enable logging, set `PERF_LOGGING_ENABLED = true` in both files. --- ## 📝 Logging Code Reference (Main Branch) The following code was added to `extension-store.ts` on main branch to capture performance metrics: ### Helper Functions (add at top of file after imports) ```typescript // ============ PERF LOGGING (for testing) ============ const PERF_LOGGING_ENABLED = true; function getSizeKB(obj: unknown): string { try { const str = JSON.stringify(obj); return (str.length / 1024).toFixed(2); } catch { return 'N/A'; } } function logControllerReadPerf( controllerName: string, data: unknown, timeMs: number, ): void { if (!PERF_LOGGING_ENABLED) { return; } const sizeKB = getSizeKB(data); console.warn( `[ControllerStorage PERF] ${controllerName} read - ${sizeKB}KB - ${timeMs.toFixed(2)}ms`, ); } function logControllerWritePerf( controllerName: string, data: unknown, timeMs: number, ): void { if (!PERF_LOGGING_ENABLED) { return; } const sizeKB = getSizeKB(data); console.warn( `[ControllerStorage PERF] ${controllerName} write - ${sizeKB}KB - ${timeMs.toFixed(2)}ms`, ); } // ============ END PERF LOGGING ============ ``` ### In `get()` method - Add at start of method: ```typescript const perfStart = performance.now(); if (PERF_LOGGING_ENABLED) { console.warn('[ControllerStorage PERF] getAllPersistedState started'); } ``` ### In `get()` method - Add after data is loaded: ```typescript // PERF: Log overall time and TokenListController size if (PERF_LOGGING_ENABLED) { const elapsed = performance.now() - perfStart; console.warn( `[ControllerStorage PERF] getAllPersistedState complete - ${elapsed.toFixed(2)}ms`, ); // Log TokenListController state size specifically if (data.TokenListController) { logControllerReadPerf( 'TokenListController', data.TokenListController, elapsed, ); } } ``` ### In `set()` method - Add logging: ```typescript const perfStart = performance.now(); // PERF: Log TokenListController size before write if ( PERF_LOGGING_ENABLED && isObject(data) && hasProperty(data, 'TokenListController') ) { const tlcSize = getSizeKB(data.TokenListController); console.warn( `[ControllerStorage PERF] set() - TokenListController size: ${tlcSize}KB`, ); } // ... existing set logic ... // PERF: Log total write time (add after await local.set()) if (PERF_LOGGING_ENABLED) { const elapsed = performance.now() - perfStart; const totalSize = getSizeKB({ data, meta }); console.warn( `[ControllerStorage PERF] set() complete - ${totalSize}KB - ${elapsed.toFixed(2)}ms`, ); } ``` ### Expected Log Output **Cold Restart:** ``` [ControllerStorage PERF] getAllPersistedState started [ControllerStorage PERF] getAllPersistedState complete - 374.30ms [ControllerStorage PERF] TokenListController read - 4601.13KB - 374.30ms ``` **Write (adding chain or background save):** ``` [ControllerStorage PERF] set() - TokenListController size: 4601.13KB [ControllerStorage PERF] set() complete - 28036.55KB - 559.30ms ``` --- ## 💡 Key Takeaways 1. **Write amplification eliminated**: Adding a single chain now writes only that chain's data (~30-200KB) instead of the entire cache (~4MB) 2. **Faster incremental updates**: Per-chain writes are significantly faster than full cache rewrites 3. **Cold restart trade-off**: Parallel file reads + getAllKeys adds some overhead vs single file read, but the difference is minimal 4. **Onboarding improvement**: Total data written during onboarding is reduced by avoiding cumulative rewrites --- ## ✅ PR Branch: Background Writes No Longer Include Token Cache On this PR branch, background writes show: ``` [ControllerStorage PERF] set() - TokenListController size: 0.04KB ← TINY! No cache! [ControllerStorage PERF] set() complete - 23425.30KB - 481.60ms ← ~23MB (not 28MB) ``` **Key proof**: TokenListController is only 0.04KB in the main state because the ~4.4MB token cache is stored separately in StorageService. --- ##⚠️ Main Branch: Continuous Background Write Amplification During testing on main branch, we observed that the **entire 27.8MB state is being rewritten repeatedly** even when the user is idle: ``` [ControllerStorage PERF] set() - TokenListController size: 4444.22KB [ControllerStorage PERF] set() complete - 27877.62KB - 632.30ms [ControllerStorage PERF] set() - TokenListController size: 4444.22KB [ControllerStorage PERF] set() complete - 27877.62KB - 456.40ms [ControllerStorage PERF] set() - TokenListController size: 4444.22KB [ControllerStorage PERF] set() complete - 27877.62KB - 606.40ms [ControllerStorage PERF] set() - TokenListController size: 4444.22KB [ControllerStorage PERF] set() complete - 27877.59KB - 625.10ms [ControllerStorage PERF] set() - TokenListController size: 4444.22KB [ControllerStorage PERF] set() complete - 27877.59KB - 597.90ms ``` ### Why This Happens MetaMask has background processes that trigger state saves: - Token balance polling - Price updates - Network status checks - Account sync - DeFi positions updates - etc. Each time ANY controller state changes, the **entire state** (~27.8MB) is serialized and written to storage, including the **4.4MB TokenListController cache that hasn't changed**. ### Impact Comparison | Metric | This PR | Main Branch | |--------|---------|-------------| | State size per write | **~23MB** | **~28MB** | | TokenListController in state | **0.04KB** | **4,601KB** | | Token cache included in saves | ❌ No | ✅ Yes (every save) | | Write time | ~480ms | ~550ms | ### How This PR Helps By moving `tokensChainsCache` to StorageService: 1. **Background saves are ~18% smaller** (~23MB instead of ~28MB) 2. **Token cache only written when it actually changes** (new chain added or cache refresh) 3. **Reduced disk I/O** - ~5MB less data serialized and written on every background save 4. **Better SSD/storage health** - less unnecessary write cycles ## **Changelog** CHANGELOG entry: No user facing changes; this only updates the storage location for tokenListController. ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Medium risk because it changes how `TokenListController` persists cached token lists and adds a state migration writing to `browser.storage.local`, which could impact startup/migration behavior if keys or storage operations fail. > > **Overview** > **Moves `TokenListController` token list caching to StorageService.** The controller messenger now allows `StorageService:*` actions and `TokenListControllerInit` fires `controller.initialize()` on startup to load cached lists from storage (logging errors but not failing init). > > **Adds migration #190 to preserve existing caches.** Migration `190` copies `tokensChainsCache` entries into per-chain `storageService:TokenListController:tokensChainsCache:{chainId}` keys without overwriting existing entries, then clears the in-state cache and bumps fixtures/snapshots to version `190`. > > **Updates tests and deps for the new storage behavior.** Jest mocks for `webextension-polyfill` are made async/shared across imports, e2e state persistence ignores StorageService-prefixed keys, and `@metamask/assets-controllers` is bumped to `^98.0.0`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 62be895. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: MetaMask Bot <metamaskbot@users.noreply.github.com>
github-merge-queue bot
pushed a commit
to MetaMask/metamask-extension
that referenced
this pull request
Jan 29, 2026
Do not merge before this gets in MetaMask/core#7413 ## **Description** # Performance Comparison: Per-Chain Token Cache Storage This PR implements per-chain file storage for `tokensChainsCache` in `TokenListController`, replacing the single-file approach. Each chain's token list is now stored in a separate file via StorageService, reducing write amplification during incremental updates. --- ## 📊 Summary | Category | This PR | Main Branch | Improvement | |----------|---------|-------------|-------------| | Cold restart (getAllPersistedState) | **216.20ms** | **374.30ms** | **42% faster** | | Cold restart (cache load) | **~310ms** (parallel) | Included in state | ----- | | Onboarding - Token cache | **4.40MB** (StorageService) | **4.40MB** (in state) | Stored separately | | Onboarding - Background saves | **~23MB** each | **~28MB** each | **~5MB less per save** | | Onboarding - Token cache in saves | ❌ No | ✅ Yes | **Eliminated** | | **Add Monad** | **39KB** (new chain only) | **~4.4MB** (full cache rewritten) | **Only new chain** | | **Add Avalanche** | **157KB** (new chain only) | **~4.6MB** (full cache rewritten) | **Only new chain** | | **Background saves (idle)** | **~23MB** | **~28MB** | **~18% smaller** | | **TokenListController in state** | **0.04KB** | **4,601KB** | **Cache moved out** | **Key Results**: 1. **Adding a single chain on main branch** triggers a state save that rewrites all TokenListController cache (~4.6MB) plus all other controllers (~23MB) = ~28MB total. 2. **This PR writes ONLY the new chain** (e.g., 39KB for Monad) to StorageService. The full token cache is NOT rewritten. 3. **Background saves are ~5MB smaller** (~23MB vs ~28MB) because the token cache is stored separately. --- ## 🧪 Test Scenarios ### Scenario 1: Cold Restart (Existing User) **Setup**: Extension with 10 networks cached (8 popular + Monad + Avalanche), then browser restart. #### This PR **Controller Storage:** ``` [ControllerStorage PERF] getAllPersistedState complete - 216.20ms ``` **StorageService - getAllKeys:** ``` [StorageService PERF] getAllKeys TokenListController - 10 keys found - 277.60ms ``` **StorageService - Per-chain getItem (parallel reads):** | Chain | Size | Read Time | |-------|------|-----------| | 0x1 (Ethereum) | 2022.39KB | 80.40ms | | 0x38 (BSC) | 1045.19KB | 125.90ms | | 0x2105 (Base) | 436.73KB | 97.20ms | | 0x89 (Polygon) | 388.91KB | 128.30ms | | 0xa4b1 (Arbitrum) | 345.79KB | 159.50ms | | 0xa86a (Avalanche) | 156.90KB | 161.60ms | | 0xa (Optimism) | 125.76KB | 132.20ms | | 0xe708 (Linea) | 40.13KB | 163.20ms | | 0x8f (Monad) | 39.15KB | 131.80ms | | 0xaa36a7 (Sepolia) | 0.04KB | 162.70ms | **Total cache size: ~4.60MB across 10 chains** #### Main Branch **Cold restart with 10 networks cached (8 popular + Monad + Avalanche):** ``` [ControllerStorage PERF] getAllPersistedState started [ControllerStorage PERF] getAllPersistedState complete - 374.30ms [ControllerStorage PERF] TokenListController read - 4601.13KB - 374.30ms ``` #### Comparison | Metric | This PR | Main Branch | Improvement | |--------|---------|-------------|-------------| | **getAllPersistedState** | **216.20ms** | **374.30ms** | **42% faster** | | TokenListController in state | **0.04KB** | **4,601KB** | Moved to StorageService | | StorageService cache load | **~163ms** (10 chains parallel) | N/A | Separate loading | | Total chains | 10 | 10 | Same | | Total state size | ~23MB | ~28MB | **~18% smaller** | **Key insight**: The main state loads **158ms faster** on this PR because it's ~5MB smaller. The token cache is loaded separately via StorageService in parallel during controller initialization. --- ### Scenario 2: Fresh Onboarding **Setup**: Fresh wallet creation, enable all popular networks, wait for token lists to fetch. #### This PR **Initial state (fresh install):** ``` [ControllerStorage PERF] getAllPersistedState complete - 13.70ms [StorageService PERF] getAllKeys TokenListController - 0 keys found - 23.50ms ``` **Per-chain writes as networks are enabled:** | Chain | Size | Stringify | Write | Total | |-------|------|-----------|-------|-------| | 0xaa36a7 (Sepolia) | 0.04KB | 0.00ms | 12.00ms | 12.00ms | | 0xe708 (Linea) | 40.13KB | 0.00ms | 81.70ms | 81.70ms | | 0xa (Optimism) | 125.76KB | 0.20ms | 81.20ms | 81.40ms | | 0x2105 (Base) | 436.73KB | 0.80ms | 79.10ms | 79.90ms | | 0x89 (Polygon) | 388.91KB | 0.80ms | 72.80ms | 73.60ms | | 0xa4b1 (Arbitrum) | 345.79KB | 0.80ms | 67.10ms | 67.90ms | | 0x1 (Ethereum) | 2022.39KB | 4.20ms | 68.40ms | 72.60ms | | 0x38 (BSC) | 1045.19KB | 1.90ms | 37.80ms | 39.70ms | **Summary:** - **Total data written**: ~4.40MB (8 individual writes) - **Number of writes**: 8 (one per chain) - **Total write time**: ~509ms (sum of individual writes) #### Main Branch **Initial state (fresh install):** ``` [ControllerStorage PERF] getAllPersistedState complete - 14.60ms [ControllerStorage PERF] set() - TokenListController size: 0.06KB [ControllerStorage PERF] set() complete - 9952.04KB - 129.10ms ``` **After clicking "All Popular Networks":** ``` [ControllerStorage PERF] set() - TokenListController size: 4405.07KB [ControllerStorage PERF] set() complete - 27830.24KB - 526.70ms ``` **Summary:** - **TokenListController size**: 4,405KB (~4.4MB) - cached in controller state - **Total state written**: 27,830KB (~27.2MB) - entire MetaMask state - **Write time**: 526.70ms #### Comparison | Metric | This PR | Main Branch | Difference | |--------|---------|-------------|------------| | Token cache data | **4.40MB** | **4.40MB** | Same amount | | Token cache location | **StorageService** (separate) | **Main state** | Separated | | Background save size | **~23MB** | **~28MB** | **~5MB smaller** | | Token cache in every save | ❌ **No** | ✅ **Yes** | **Eliminated** | **Key insight**: Both branches have continuous background saves. On main branch, every save includes the ~4.4MB token cache. On this PR, the token cache is stored separately via StorageService, making each background save ~5MB smaller. --- ### Scenario 3: Add New Chain **Setup**: Existing wallet with cached networks, add a new network. #### This PR **Avalanche (0xa86a)**: ``` [StorageService PERF] setItem TokenListController:tokensChainsCache:0xa86a - 156.90KB - stringify: 0.80ms, write: 137.20ms, total: 138.00ms ``` **Monad (0x8f)**: ``` [StorageService PERF] setItem TokenListController:tokensChainsCache:0x8f - 39.15KB - stringify: 0.00ms, write: 2.40ms, total: 2.40ms ``` **zkSync Era (0x144)**: ``` [StorageService PERF] setItem TokenListController:tokensChainsCache:0x144 - 12.98KB - stringify: 0.00ms, write: 0.90ms, total: 0.90ms ``` **Polygon (0x89)**: ``` [StorageService PERF] setItem TokenListController:tokensChainsCache:0x89 - 388.91KB - stringify: 0.90ms, write: 15.40ms, total: 16.30ms ``` #### Main Branch **Adding Monad (0x8f) to existing cache:** ``` [ControllerStorage PERF] set() - TokenListController size: 4444.22KB [ControllerStorage PERF] set() complete - 27877.62KB - 569.00ms ``` **Adding Avalanche (0xa86a) to existing cache:** ``` [ControllerStorage PERF] set() - TokenListController size: 4601.13KB [ControllerStorage PERF] set() complete - 28036.55KB - 559.30ms ``` **Note**: Each chain addition triggers a full state save that includes ALL TokenListController cache (~4.6MB) plus ALL other controllers (~23MB) = ~28MB total. #### Comparison **Token cache write for new chain:** | Chain | This PR | Main Branch | Difference | |-------|---------|-------------|------------| | **Monad (39KB)** | 39KB to StorageService | Full ~4.4MB cache rewritten | **Only new chain written** | | **Avalanche (157KB)** | 157KB to StorageService | Full ~4.6MB cache rewritten | **Only new chain written** | **Total state save triggered:** | Metric | This PR | Main Branch | Difference | |--------|---------|-------------|------------| | State size | ~23MB (no cache) | ~28MB (with cache) | **~5MB smaller** | | Token cache included | ❌ No | ✅ Yes | **Separated** | | New chain write | **39-157KB** (separate file) | Included in 28MB | **Isolated** | --- ## 📋 Raw Logs ### This PR - Cold Restart ``` [ControllerStorage PERF] getAllPersistedState started [ControllerStorage PERF] getAllPersistedState complete - 216.20ms [StorageService PERF] getAllKeys TokenListController - 10 keys found - 277.60ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0x1 - 2022.39KB - read: 80.40ms, total: 80.40ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0x2105 - 436.73KB - read: 97.20ms, total: 97.20ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0x38 - 1045.19KB - read: 125.80ms, total: 125.90ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0x89 - 388.91KB - read: 128.30ms, total: 128.30ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0x8f - 39.15KB - read: 131.80ms, total: 131.80ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0xa - 125.76KB - read: 132.20ms, total: 132.20ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0xa4b1 - 345.79KB - read: 159.50ms, total: 159.50ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0xa86a - 156.90KB - read: 161.60ms, total: 161.60ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0xaa36a7 - 0.04KB - read: 162.70ms, total: 162.70ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0xe708 - 40.13KB - read: 163.20ms, total: 163.20ms ``` ### This PR - Onboarding ``` [ControllerStorage PERF] getAllPersistedState started [ControllerStorage PERF] getAllPersistedState complete - 13.70ms [StorageService PERF] getAllKeys TokenListController - 0 keys found - 23.50ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0xaa36a7 - 0.04KB - stringify: 0.00ms, write: 12.00ms, total: 12.00ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0xe708 - 40.13KB - stringify: 0.00ms, write: 81.70ms, total: 81.70ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0xa - 125.76KB - stringify: 0.20ms, write: 81.20ms, total: 81.40ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0x2105 - 436.73KB - stringify: 0.80ms, write: 79.10ms, total: 79.90ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0x89 - 388.91KB - stringify: 0.80ms, write: 72.80ms, total: 73.60ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0xa4b1 - 345.79KB - stringify: 0.80ms, write: 67.10ms, total: 67.90ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0x1 - 2022.39KB - stringify: 4.20ms, write: 68.40ms, total: 72.60ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0x38 - 1045.19KB - stringify: 1.90ms, write: 37.80ms, total: 39.70ms ``` ### This PR - Add New Chain ``` # Adding Monad (39KB) [StorageService PERF] setItem TokenListController:tokensChainsCache:0x8f - 39.15KB - stringify: 0.10ms, write: 1.60ms, total: 1.70ms [ControllerStorage PERF] set() - TokenListController size: 0.04KB [ControllerStorage PERF] set() complete - 23433.75KB - 418.90ms # Adding Avalanche (157KB) [StorageService PERF] setItem TokenListController:tokensChainsCache:0xa86a - 156.90KB - stringify: 0.50ms, write: 5.30ms, total: 5.80ms [ControllerStorage PERF] set() - TokenListController size: 0.04KB [ControllerStorage PERF] set() complete - 23435.80KB - 416.10ms ``` ### Main Branch - Cold Restart ``` [ControllerStorage PERF] getAllPersistedState started [ControllerStorage PERF] getAllPersistedState complete - 374.30ms [ControllerStorage PERF] TokenListController read - 4601.13KB - 374.30ms [ControllerStorage PERF] set() - TokenListController size: 4601.13KB [ControllerStorage PERF] set() complete - 28033.23KB - 426.60ms [ControllerStorage PERF] set() - TokenListController size: 4601.13KB [ControllerStorage PERF] set() complete - 28033.01KB - 564.00ms ``` ### Main Branch - Onboarding ``` [ControllerStorage PERF] getAllPersistedState started [ControllerStorage PERF] getAllPersistedState complete - 14.60ms [ControllerStorage PERF] set() complete - 0.06KB - 0.30ms [ControllerStorage PERF] set() - TokenListController size: 0.06KB [ControllerStorage PERF] set() complete - 9952.04KB - 129.10ms [ControllerStorage PERF] set() - TokenListController size: 0.06KB [ControllerStorage PERF] set() complete - 9952.30KB - 130.20ms [ControllerStorage PERF] set() - TokenListController size: 0.06KB [ControllerStorage PERF] set() complete - 9952.67KB - 136.10ms [ControllerStorage PERF] set() - TokenListController size: 0.06KB [ControllerStorage PERF] set() complete - 9952.97KB - 136.50ms [ControllerStorage PERF] set() - TokenListController size: 0.06KB [ControllerStorage PERF] set() complete - 9955.72KB - 142.20ms [ControllerStorage PERF] set() - TokenListController size: 4405.07KB [ControllerStorage PERF] set() complete - 27830.24KB - 526.70ms ``` ### Main Branch - Add New Chain (Monad + Avalanche) ``` # Adding Monad (39KB) [ControllerStorage PERF] set() - TokenListController size: 4444.22KB [ControllerStorage PERF] set() complete - 27877.62KB - 569.00ms # Adding Avalanche (157KB) [ControllerStorage PERF] set() - TokenListController size: 4601.13KB [ControllerStorage PERF] set() complete - 28036.55KB - 559.30ms ``` --- ## 🔧 How Performance Was Measured Performance logging was added to: 1. **BrowserStorageAdapter** (`app/scripts/lib/stores/browser-storage-adapter.ts`) - Logs for `getItem`, `setItem`, `getAllKeys` operations - Measures read time, stringify time, write time, and data size 2. **ExtensionStore** (`app/scripts/lib/stores/extension-store.ts`) - Logs for `getAllPersistedState` and controller state writes - Measures TokenListController-specific read/write performance To enable logging, set `PERF_LOGGING_ENABLED = true` in both files. --- ## 📝 Logging Code Reference (Main Branch) The following code was added to `extension-store.ts` on main branch to capture performance metrics: ### Helper Functions (add at top of file after imports) ```typescript // ============ PERF LOGGING (for testing) ============ const PERF_LOGGING_ENABLED = true; function getSizeKB(obj: unknown): string { try { const str = JSON.stringify(obj); return (str.length / 1024).toFixed(2); } catch { return 'N/A'; } } function logControllerReadPerf( controllerName: string, data: unknown, timeMs: number, ): void { if (!PERF_LOGGING_ENABLED) { return; } const sizeKB = getSizeKB(data); console.warn( `[ControllerStorage PERF] ${controllerName} read - ${sizeKB}KB - ${timeMs.toFixed(2)}ms`, ); } function logControllerWritePerf( controllerName: string, data: unknown, timeMs: number, ): void { if (!PERF_LOGGING_ENABLED) { return; } const sizeKB = getSizeKB(data); console.warn( `[ControllerStorage PERF] ${controllerName} write - ${sizeKB}KB - ${timeMs.toFixed(2)}ms`, ); } // ============ END PERF LOGGING ============ ``` ### In `get()` method - Add at start of method: ```typescript const perfStart = performance.now(); if (PERF_LOGGING_ENABLED) { console.warn('[ControllerStorage PERF] getAllPersistedState started'); } ``` ### In `get()` method - Add after data is loaded: ```typescript // PERF: Log overall time and TokenListController size if (PERF_LOGGING_ENABLED) { const elapsed = performance.now() - perfStart; console.warn( `[ControllerStorage PERF] getAllPersistedState complete - ${elapsed.toFixed(2)}ms`, ); // Log TokenListController state size specifically if (data.TokenListController) { logControllerReadPerf( 'TokenListController', data.TokenListController, elapsed, ); } } ``` ### In `set()` method - Add logging: ```typescript const perfStart = performance.now(); // PERF: Log TokenListController size before write if ( PERF_LOGGING_ENABLED && isObject(data) && hasProperty(data, 'TokenListController') ) { const tlcSize = getSizeKB(data.TokenListController); console.warn( `[ControllerStorage PERF] set() - TokenListController size: ${tlcSize}KB`, ); } // ... existing set logic ... // PERF: Log total write time (add after await local.set()) if (PERF_LOGGING_ENABLED) { const elapsed = performance.now() - perfStart; const totalSize = getSizeKB({ data, meta }); console.warn( `[ControllerStorage PERF] set() complete - ${totalSize}KB - ${elapsed.toFixed(2)}ms`, ); } ``` ### Expected Log Output **Cold Restart:** ``` [ControllerStorage PERF] getAllPersistedState started [ControllerStorage PERF] getAllPersistedState complete - 374.30ms [ControllerStorage PERF] TokenListController read - 4601.13KB - 374.30ms ``` **Write (adding chain or background save):** ``` [ControllerStorage PERF] set() - TokenListController size: 4601.13KB [ControllerStorage PERF] set() complete - 28036.55KB - 559.30ms ``` --- ## 💡 Key Takeaways 1. **Write amplification eliminated**: Adding a single chain now writes only that chain's data (~30-200KB) instead of the entire cache (~4MB) 2. **Faster incremental updates**: Per-chain writes are significantly faster than full cache rewrites 3. **Cold restart trade-off**: Parallel file reads + getAllKeys adds some overhead vs single file read, but the difference is minimal 4. **Onboarding improvement**: Total data written during onboarding is reduced by avoiding cumulative rewrites --- ## ✅ PR Branch: Background Writes No Longer Include Token Cache On this PR branch, background writes show: ``` [ControllerStorage PERF] set() - TokenListController size: 0.04KB ← TINY! No cache! [ControllerStorage PERF] set() complete - 23425.30KB - 481.60ms ← ~23MB (not 28MB) ``` **Key proof**: TokenListController is only 0.04KB in the main state because the ~4.4MB token cache is stored separately in StorageService. --- ##⚠️ Main Branch: Continuous Background Write Amplification During testing on main branch, we observed that the **entire 27.8MB state is being rewritten repeatedly** even when the user is idle: ``` [ControllerStorage PERF] set() - TokenListController size: 4444.22KB [ControllerStorage PERF] set() complete - 27877.62KB - 632.30ms [ControllerStorage PERF] set() - TokenListController size: 4444.22KB [ControllerStorage PERF] set() complete - 27877.62KB - 456.40ms [ControllerStorage PERF] set() - TokenListController size: 4444.22KB [ControllerStorage PERF] set() complete - 27877.62KB - 606.40ms [ControllerStorage PERF] set() - TokenListController size: 4444.22KB [ControllerStorage PERF] set() complete - 27877.59KB - 625.10ms [ControllerStorage PERF] set() - TokenListController size: 4444.22KB [ControllerStorage PERF] set() complete - 27877.59KB - 597.90ms ``` ### Why This Happens MetaMask has background processes that trigger state saves: - Token balance polling - Price updates - Network status checks - Account sync - DeFi positions updates - etc. Each time ANY controller state changes, the **entire state** (~27.8MB) is serialized and written to storage, including the **4.4MB TokenListController cache that hasn't changed**. ### Impact Comparison | Metric | This PR | Main Branch | |--------|---------|-------------| | State size per write | **~23MB** | **~28MB** | | TokenListController in state | **0.04KB** | **4,601KB** | | Token cache included in saves | ❌ No | ✅ Yes (every save) | | Write time | ~480ms | ~550ms | ### How This PR Helps By moving `tokensChainsCache` to StorageService: 1. **Background saves are ~18% smaller** (~23MB instead of ~28MB) 2. **Token cache only written when it actually changes** (new chain added or cache refresh) 3. **Reduced disk I/O** - ~5MB less data serialized and written on every background save 4. **Better SSD/storage health** - less unnecessary write cycles ## **Changelog** CHANGELOG entry: No user facing changes; this only updates the storage location for tokenListController. ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Medium risk because it changes how `TokenListController` persists cached token lists and adds a state migration writing to `browser.storage.local`, which could impact startup/migration behavior if keys or storage operations fail. > > **Overview** > **Moves `TokenListController` token list caching to StorageService.** The controller messenger now allows `StorageService:*` actions and `TokenListControllerInit` fires `controller.initialize()` on startup to load cached lists from storage (logging errors but not failing init). > > **Adds migration #190 to preserve existing caches.** Migration `190` copies `tokensChainsCache` entries into per-chain `storageService:TokenListController:tokensChainsCache:{chainId}` keys without overwriting existing entries, then clears the in-state cache and bumps fixtures/snapshots to version `190`. > > **Updates tests and deps for the new storage behavior.** Jest mocks for `webextension-polyfill` are made async/shared across imports, e2e state persistence ignores StorageService-prefixed keys, and `@metamask/assets-controllers` is bumped to `^98.0.0`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 62be895. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: MetaMask Bot <metamaskbot@users.noreply.github.com>
github-merge-queue bot
pushed a commit
to MetaMask/metamask-extension
that referenced
this pull request
Jan 29, 2026
Do not merge before this gets in MetaMask/core#7413 ## **Description** # Performance Comparison: Per-Chain Token Cache Storage This PR implements per-chain file storage for `tokensChainsCache` in `TokenListController`, replacing the single-file approach. Each chain's token list is now stored in a separate file via StorageService, reducing write amplification during incremental updates. --- ## 📊 Summary | Category | This PR | Main Branch | Improvement | |----------|---------|-------------|-------------| | Cold restart (getAllPersistedState) | **216.20ms** | **374.30ms** | **42% faster** | | Cold restart (cache load) | **~310ms** (parallel) | Included in state | ----- | | Onboarding - Token cache | **4.40MB** (StorageService) | **4.40MB** (in state) | Stored separately | | Onboarding - Background saves | **~23MB** each | **~28MB** each | **~5MB less per save** | | Onboarding - Token cache in saves | ❌ No | ✅ Yes | **Eliminated** | | **Add Monad** | **39KB** (new chain only) | **~4.4MB** (full cache rewritten) | **Only new chain** | | **Add Avalanche** | **157KB** (new chain only) | **~4.6MB** (full cache rewritten) | **Only new chain** | | **Background saves (idle)** | **~23MB** | **~28MB** | **~18% smaller** | | **TokenListController in state** | **0.04KB** | **4,601KB** | **Cache moved out** | **Key Results**: 1. **Adding a single chain on main branch** triggers a state save that rewrites all TokenListController cache (~4.6MB) plus all other controllers (~23MB) = ~28MB total. 2. **This PR writes ONLY the new chain** (e.g., 39KB for Monad) to StorageService. The full token cache is NOT rewritten. 3. **Background saves are ~5MB smaller** (~23MB vs ~28MB) because the token cache is stored separately. --- ## 🧪 Test Scenarios ### Scenario 1: Cold Restart (Existing User) **Setup**: Extension with 10 networks cached (8 popular + Monad + Avalanche), then browser restart. #### This PR **Controller Storage:** ``` [ControllerStorage PERF] getAllPersistedState complete - 216.20ms ``` **StorageService - getAllKeys:** ``` [StorageService PERF] getAllKeys TokenListController - 10 keys found - 277.60ms ``` **StorageService - Per-chain getItem (parallel reads):** | Chain | Size | Read Time | |-------|------|-----------| | 0x1 (Ethereum) | 2022.39KB | 80.40ms | | 0x38 (BSC) | 1045.19KB | 125.90ms | | 0x2105 (Base) | 436.73KB | 97.20ms | | 0x89 (Polygon) | 388.91KB | 128.30ms | | 0xa4b1 (Arbitrum) | 345.79KB | 159.50ms | | 0xa86a (Avalanche) | 156.90KB | 161.60ms | | 0xa (Optimism) | 125.76KB | 132.20ms | | 0xe708 (Linea) | 40.13KB | 163.20ms | | 0x8f (Monad) | 39.15KB | 131.80ms | | 0xaa36a7 (Sepolia) | 0.04KB | 162.70ms | **Total cache size: ~4.60MB across 10 chains** #### Main Branch **Cold restart with 10 networks cached (8 popular + Monad + Avalanche):** ``` [ControllerStorage PERF] getAllPersistedState started [ControllerStorage PERF] getAllPersistedState complete - 374.30ms [ControllerStorage PERF] TokenListController read - 4601.13KB - 374.30ms ``` #### Comparison | Metric | This PR | Main Branch | Improvement | |--------|---------|-------------|-------------| | **getAllPersistedState** | **216.20ms** | **374.30ms** | **42% faster** | | TokenListController in state | **0.04KB** | **4,601KB** | Moved to StorageService | | StorageService cache load | **~163ms** (10 chains parallel) | N/A | Separate loading | | Total chains | 10 | 10 | Same | | Total state size | ~23MB | ~28MB | **~18% smaller** | **Key insight**: The main state loads **158ms faster** on this PR because it's ~5MB smaller. The token cache is loaded separately via StorageService in parallel during controller initialization. --- ### Scenario 2: Fresh Onboarding **Setup**: Fresh wallet creation, enable all popular networks, wait for token lists to fetch. #### This PR **Initial state (fresh install):** ``` [ControllerStorage PERF] getAllPersistedState complete - 13.70ms [StorageService PERF] getAllKeys TokenListController - 0 keys found - 23.50ms ``` **Per-chain writes as networks are enabled:** | Chain | Size | Stringify | Write | Total | |-------|------|-----------|-------|-------| | 0xaa36a7 (Sepolia) | 0.04KB | 0.00ms | 12.00ms | 12.00ms | | 0xe708 (Linea) | 40.13KB | 0.00ms | 81.70ms | 81.70ms | | 0xa (Optimism) | 125.76KB | 0.20ms | 81.20ms | 81.40ms | | 0x2105 (Base) | 436.73KB | 0.80ms | 79.10ms | 79.90ms | | 0x89 (Polygon) | 388.91KB | 0.80ms | 72.80ms | 73.60ms | | 0xa4b1 (Arbitrum) | 345.79KB | 0.80ms | 67.10ms | 67.90ms | | 0x1 (Ethereum) | 2022.39KB | 4.20ms | 68.40ms | 72.60ms | | 0x38 (BSC) | 1045.19KB | 1.90ms | 37.80ms | 39.70ms | **Summary:** - **Total data written**: ~4.40MB (8 individual writes) - **Number of writes**: 8 (one per chain) - **Total write time**: ~509ms (sum of individual writes) #### Main Branch **Initial state (fresh install):** ``` [ControllerStorage PERF] getAllPersistedState complete - 14.60ms [ControllerStorage PERF] set() - TokenListController size: 0.06KB [ControllerStorage PERF] set() complete - 9952.04KB - 129.10ms ``` **After clicking "All Popular Networks":** ``` [ControllerStorage PERF] set() - TokenListController size: 4405.07KB [ControllerStorage PERF] set() complete - 27830.24KB - 526.70ms ``` **Summary:** - **TokenListController size**: 4,405KB (~4.4MB) - cached in controller state - **Total state written**: 27,830KB (~27.2MB) - entire MetaMask state - **Write time**: 526.70ms #### Comparison | Metric | This PR | Main Branch | Difference | |--------|---------|-------------|------------| | Token cache data | **4.40MB** | **4.40MB** | Same amount | | Token cache location | **StorageService** (separate) | **Main state** | Separated | | Background save size | **~23MB** | **~28MB** | **~5MB smaller** | | Token cache in every save | ❌ **No** | ✅ **Yes** | **Eliminated** | **Key insight**: Both branches have continuous background saves. On main branch, every save includes the ~4.4MB token cache. On this PR, the token cache is stored separately via StorageService, making each background save ~5MB smaller. --- ### Scenario 3: Add New Chain **Setup**: Existing wallet with cached networks, add a new network. #### This PR **Avalanche (0xa86a)**: ``` [StorageService PERF] setItem TokenListController:tokensChainsCache:0xa86a - 156.90KB - stringify: 0.80ms, write: 137.20ms, total: 138.00ms ``` **Monad (0x8f)**: ``` [StorageService PERF] setItem TokenListController:tokensChainsCache:0x8f - 39.15KB - stringify: 0.00ms, write: 2.40ms, total: 2.40ms ``` **zkSync Era (0x144)**: ``` [StorageService PERF] setItem TokenListController:tokensChainsCache:0x144 - 12.98KB - stringify: 0.00ms, write: 0.90ms, total: 0.90ms ``` **Polygon (0x89)**: ``` [StorageService PERF] setItem TokenListController:tokensChainsCache:0x89 - 388.91KB - stringify: 0.90ms, write: 15.40ms, total: 16.30ms ``` #### Main Branch **Adding Monad (0x8f) to existing cache:** ``` [ControllerStorage PERF] set() - TokenListController size: 4444.22KB [ControllerStorage PERF] set() complete - 27877.62KB - 569.00ms ``` **Adding Avalanche (0xa86a) to existing cache:** ``` [ControllerStorage PERF] set() - TokenListController size: 4601.13KB [ControllerStorage PERF] set() complete - 28036.55KB - 559.30ms ``` **Note**: Each chain addition triggers a full state save that includes ALL TokenListController cache (~4.6MB) plus ALL other controllers (~23MB) = ~28MB total. #### Comparison **Token cache write for new chain:** | Chain | This PR | Main Branch | Difference | |-------|---------|-------------|------------| | **Monad (39KB)** | 39KB to StorageService | Full ~4.4MB cache rewritten | **Only new chain written** | | **Avalanche (157KB)** | 157KB to StorageService | Full ~4.6MB cache rewritten | **Only new chain written** | **Total state save triggered:** | Metric | This PR | Main Branch | Difference | |--------|---------|-------------|------------| | State size | ~23MB (no cache) | ~28MB (with cache) | **~5MB smaller** | | Token cache included | ❌ No | ✅ Yes | **Separated** | | New chain write | **39-157KB** (separate file) | Included in 28MB | **Isolated** | --- ## 📋 Raw Logs ### This PR - Cold Restart ``` [ControllerStorage PERF] getAllPersistedState started [ControllerStorage PERF] getAllPersistedState complete - 216.20ms [StorageService PERF] getAllKeys TokenListController - 10 keys found - 277.60ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0x1 - 2022.39KB - read: 80.40ms, total: 80.40ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0x2105 - 436.73KB - read: 97.20ms, total: 97.20ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0x38 - 1045.19KB - read: 125.80ms, total: 125.90ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0x89 - 388.91KB - read: 128.30ms, total: 128.30ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0x8f - 39.15KB - read: 131.80ms, total: 131.80ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0xa - 125.76KB - read: 132.20ms, total: 132.20ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0xa4b1 - 345.79KB - read: 159.50ms, total: 159.50ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0xa86a - 156.90KB - read: 161.60ms, total: 161.60ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0xaa36a7 - 0.04KB - read: 162.70ms, total: 162.70ms [StorageService PERF] getItem TokenListController:tokensChainsCache:0xe708 - 40.13KB - read: 163.20ms, total: 163.20ms ``` ### This PR - Onboarding ``` [ControllerStorage PERF] getAllPersistedState started [ControllerStorage PERF] getAllPersistedState complete - 13.70ms [StorageService PERF] getAllKeys TokenListController - 0 keys found - 23.50ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0xaa36a7 - 0.04KB - stringify: 0.00ms, write: 12.00ms, total: 12.00ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0xe708 - 40.13KB - stringify: 0.00ms, write: 81.70ms, total: 81.70ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0xa - 125.76KB - stringify: 0.20ms, write: 81.20ms, total: 81.40ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0x2105 - 436.73KB - stringify: 0.80ms, write: 79.10ms, total: 79.90ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0x89 - 388.91KB - stringify: 0.80ms, write: 72.80ms, total: 73.60ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0xa4b1 - 345.79KB - stringify: 0.80ms, write: 67.10ms, total: 67.90ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0x1 - 2022.39KB - stringify: 4.20ms, write: 68.40ms, total: 72.60ms [StorageService PERF] setItem TokenListController:tokensChainsCache:0x38 - 1045.19KB - stringify: 1.90ms, write: 37.80ms, total: 39.70ms ``` ### This PR - Add New Chain ``` # Adding Monad (39KB) [StorageService PERF] setItem TokenListController:tokensChainsCache:0x8f - 39.15KB - stringify: 0.10ms, write: 1.60ms, total: 1.70ms [ControllerStorage PERF] set() - TokenListController size: 0.04KB [ControllerStorage PERF] set() complete - 23433.75KB - 418.90ms # Adding Avalanche (157KB) [StorageService PERF] setItem TokenListController:tokensChainsCache:0xa86a - 156.90KB - stringify: 0.50ms, write: 5.30ms, total: 5.80ms [ControllerStorage PERF] set() - TokenListController size: 0.04KB [ControllerStorage PERF] set() complete - 23435.80KB - 416.10ms ``` ### Main Branch - Cold Restart ``` [ControllerStorage PERF] getAllPersistedState started [ControllerStorage PERF] getAllPersistedState complete - 374.30ms [ControllerStorage PERF] TokenListController read - 4601.13KB - 374.30ms [ControllerStorage PERF] set() - TokenListController size: 4601.13KB [ControllerStorage PERF] set() complete - 28033.23KB - 426.60ms [ControllerStorage PERF] set() - TokenListController size: 4601.13KB [ControllerStorage PERF] set() complete - 28033.01KB - 564.00ms ``` ### Main Branch - Onboarding ``` [ControllerStorage PERF] getAllPersistedState started [ControllerStorage PERF] getAllPersistedState complete - 14.60ms [ControllerStorage PERF] set() complete - 0.06KB - 0.30ms [ControllerStorage PERF] set() - TokenListController size: 0.06KB [ControllerStorage PERF] set() complete - 9952.04KB - 129.10ms [ControllerStorage PERF] set() - TokenListController size: 0.06KB [ControllerStorage PERF] set() complete - 9952.30KB - 130.20ms [ControllerStorage PERF] set() - TokenListController size: 0.06KB [ControllerStorage PERF] set() complete - 9952.67KB - 136.10ms [ControllerStorage PERF] set() - TokenListController size: 0.06KB [ControllerStorage PERF] set() complete - 9952.97KB - 136.50ms [ControllerStorage PERF] set() - TokenListController size: 0.06KB [ControllerStorage PERF] set() complete - 9955.72KB - 142.20ms [ControllerStorage PERF] set() - TokenListController size: 4405.07KB [ControllerStorage PERF] set() complete - 27830.24KB - 526.70ms ``` ### Main Branch - Add New Chain (Monad + Avalanche) ``` # Adding Monad (39KB) [ControllerStorage PERF] set() - TokenListController size: 4444.22KB [ControllerStorage PERF] set() complete - 27877.62KB - 569.00ms # Adding Avalanche (157KB) [ControllerStorage PERF] set() - TokenListController size: 4601.13KB [ControllerStorage PERF] set() complete - 28036.55KB - 559.30ms ``` --- ## 🔧 How Performance Was Measured Performance logging was added to: 1. **BrowserStorageAdapter** (`app/scripts/lib/stores/browser-storage-adapter.ts`) - Logs for `getItem`, `setItem`, `getAllKeys` operations - Measures read time, stringify time, write time, and data size 2. **ExtensionStore** (`app/scripts/lib/stores/extension-store.ts`) - Logs for `getAllPersistedState` and controller state writes - Measures TokenListController-specific read/write performance To enable logging, set `PERF_LOGGING_ENABLED = true` in both files. --- ## 📝 Logging Code Reference (Main Branch) The following code was added to `extension-store.ts` on main branch to capture performance metrics: ### Helper Functions (add at top of file after imports) ```typescript // ============ PERF LOGGING (for testing) ============ const PERF_LOGGING_ENABLED = true; function getSizeKB(obj: unknown): string { try { const str = JSON.stringify(obj); return (str.length / 1024).toFixed(2); } catch { return 'N/A'; } } function logControllerReadPerf( controllerName: string, data: unknown, timeMs: number, ): void { if (!PERF_LOGGING_ENABLED) { return; } const sizeKB = getSizeKB(data); console.warn( `[ControllerStorage PERF] ${controllerName} read - ${sizeKB}KB - ${timeMs.toFixed(2)}ms`, ); } function logControllerWritePerf( controllerName: string, data: unknown, timeMs: number, ): void { if (!PERF_LOGGING_ENABLED) { return; } const sizeKB = getSizeKB(data); console.warn( `[ControllerStorage PERF] ${controllerName} write - ${sizeKB}KB - ${timeMs.toFixed(2)}ms`, ); } // ============ END PERF LOGGING ============ ``` ### In `get()` method - Add at start of method: ```typescript const perfStart = performance.now(); if (PERF_LOGGING_ENABLED) { console.warn('[ControllerStorage PERF] getAllPersistedState started'); } ``` ### In `get()` method - Add after data is loaded: ```typescript // PERF: Log overall time and TokenListController size if (PERF_LOGGING_ENABLED) { const elapsed = performance.now() - perfStart; console.warn( `[ControllerStorage PERF] getAllPersistedState complete - ${elapsed.toFixed(2)}ms`, ); // Log TokenListController state size specifically if (data.TokenListController) { logControllerReadPerf( 'TokenListController', data.TokenListController, elapsed, ); } } ``` ### In `set()` method - Add logging: ```typescript const perfStart = performance.now(); // PERF: Log TokenListController size before write if ( PERF_LOGGING_ENABLED && isObject(data) && hasProperty(data, 'TokenListController') ) { const tlcSize = getSizeKB(data.TokenListController); console.warn( `[ControllerStorage PERF] set() - TokenListController size: ${tlcSize}KB`, ); } // ... existing set logic ... // PERF: Log total write time (add after await local.set()) if (PERF_LOGGING_ENABLED) { const elapsed = performance.now() - perfStart; const totalSize = getSizeKB({ data, meta }); console.warn( `[ControllerStorage PERF] set() complete - ${totalSize}KB - ${elapsed.toFixed(2)}ms`, ); } ``` ### Expected Log Output **Cold Restart:** ``` [ControllerStorage PERF] getAllPersistedState started [ControllerStorage PERF] getAllPersistedState complete - 374.30ms [ControllerStorage PERF] TokenListController read - 4601.13KB - 374.30ms ``` **Write (adding chain or background save):** ``` [ControllerStorage PERF] set() - TokenListController size: 4601.13KB [ControllerStorage PERF] set() complete - 28036.55KB - 559.30ms ``` --- ## 💡 Key Takeaways 1. **Write amplification eliminated**: Adding a single chain now writes only that chain's data (~30-200KB) instead of the entire cache (~4MB) 2. **Faster incremental updates**: Per-chain writes are significantly faster than full cache rewrites 3. **Cold restart trade-off**: Parallel file reads + getAllKeys adds some overhead vs single file read, but the difference is minimal 4. **Onboarding improvement**: Total data written during onboarding is reduced by avoiding cumulative rewrites --- ## ✅ PR Branch: Background Writes No Longer Include Token Cache On this PR branch, background writes show: ``` [ControllerStorage PERF] set() - TokenListController size: 0.04KB ← TINY! No cache! [ControllerStorage PERF] set() complete - 23425.30KB - 481.60ms ← ~23MB (not 28MB) ``` **Key proof**: TokenListController is only 0.04KB in the main state because the ~4.4MB token cache is stored separately in StorageService. --- ##⚠️ Main Branch: Continuous Background Write Amplification During testing on main branch, we observed that the **entire 27.8MB state is being rewritten repeatedly** even when the user is idle: ``` [ControllerStorage PERF] set() - TokenListController size: 4444.22KB [ControllerStorage PERF] set() complete - 27877.62KB - 632.30ms [ControllerStorage PERF] set() - TokenListController size: 4444.22KB [ControllerStorage PERF] set() complete - 27877.62KB - 456.40ms [ControllerStorage PERF] set() - TokenListController size: 4444.22KB [ControllerStorage PERF] set() complete - 27877.62KB - 606.40ms [ControllerStorage PERF] set() - TokenListController size: 4444.22KB [ControllerStorage PERF] set() complete - 27877.59KB - 625.10ms [ControllerStorage PERF] set() - TokenListController size: 4444.22KB [ControllerStorage PERF] set() complete - 27877.59KB - 597.90ms ``` ### Why This Happens MetaMask has background processes that trigger state saves: - Token balance polling - Price updates - Network status checks - Account sync - DeFi positions updates - etc. Each time ANY controller state changes, the **entire state** (~27.8MB) is serialized and written to storage, including the **4.4MB TokenListController cache that hasn't changed**. ### Impact Comparison | Metric | This PR | Main Branch | |--------|---------|-------------| | State size per write | **~23MB** | **~28MB** | | TokenListController in state | **0.04KB** | **4,601KB** | | Token cache included in saves | ❌ No | ✅ Yes (every save) | | Write time | ~480ms | ~550ms | ### How This PR Helps By moving `tokensChainsCache` to StorageService: 1. **Background saves are ~18% smaller** (~23MB instead of ~28MB) 2. **Token cache only written when it actually changes** (new chain added or cache refresh) 3. **Reduced disk I/O** - ~5MB less data serialized and written on every background save 4. **Better SSD/storage health** - less unnecessary write cycles ## **Changelog** CHANGELOG entry: No user facing changes; this only updates the storage location for tokenListController. ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Medium risk because it changes how `TokenListController` persists cached token lists and adds a state migration writing to `browser.storage.local`, which could impact startup/migration behavior if keys or storage operations fail. > > **Overview** > **Moves `TokenListController` token list caching to StorageService.** The controller messenger now allows `StorageService:*` actions and `TokenListControllerInit` fires `controller.initialize()` on startup to load cached lists from storage (logging errors but not failing init). > > **Adds migration #190 to preserve existing caches.** Migration `190` copies `tokensChainsCache` entries into per-chain `storageService:TokenListController:tokensChainsCache:{chainId}` keys without overwriting existing entries, then clears the in-state cache and bumps fixtures/snapshots to version `190`. > > **Updates tests and deps for the new storage behavior.** Jest mocks for `webextension-polyfill` are made async/shared across imports, e2e state persistence ignores StorageService-prefixed keys, and `@metamask/assets-controllers` is bumped to `^98.0.0`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 62be895. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: MetaMask Bot <metamaskbot@users.noreply.github.com>
github-merge-queue bot
pushed a commit
that referenced
this pull request
Jan 30, 2026
## Explanation There was some complexity in handling the order of operations between controller initialization and the event to persist state changes. To simplify this, the controller has been updated such that it no longer persists state changes until _after_ initialization. This makes the logic easier to follow, and lets us delete an instance variable and a few blocks of code. The controller will be initialized as part of wallet initialization, so it will not be "constructed but uninitialized" for any significant length of time. ## References Related to changes made in this PR: #7413 ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [x] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes when `TokenListController` starts persisting `tokensChainsCache` and adjusts initialization/storage synchronization logic; regressions could cause missed or unintended StorageService writes/overwrites during startup. > > **Overview** > **`TokenListController` no longer subscribes to `stateChange` for debounced StorageService persistence until `initialize()` is called**, simplifying construction-time behavior. > > Initialization/storage sync logic is simplified by removing the “loaded-from-storage skip” tracking and by scheduling persistence for any chains already present in state at init time. Tests are updated to explicitly call `initialize()` before asserting persistence, and new cases cover *no persistence before init* and *persisting updates that occurred prior to init*. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 6060063. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Optimizes TokenListController storage to reduce write amplification by persisting tokensChainsCache via StorageService using per-chain files instead of a single monolithic state property.
Mobile: MetaMask/metamask-mobile#24019
Extension: MetaMask/metamask-extension#39250
Related: https://github.com/MetaMask/metamask-mobile/pull/22943/files
Related: https://github.com/MetaMask/decisions/pull/110
Related: #7192
Explanation
The tokensChainsCache (~5MB total, containing token lists for all chains) was persisted as part of the controller state. Every time a single chain's token list was updated (~100-500KB), the entire ~5MB cache was rewritten to disk, causing:
Solution
Per-Chain File Storage:
Each chain's cache is now stored in a separate file (e.g., tokensChainsCache:0x1, tokensChainsCache:0x89)
Only the updated chain (~100-500KB) is written on each token fetch, reducing write operations by ~90-95%
All chains are loaded in parallel at startup to maintain compatibility with TokenDetectionController
Key Changes:
References
Checklist
Note
Moves
TokenListControllertoken cache to per-chain persistence viaStorageService, reducing write amplification and decoupling it from controller state.tokensChainsCacheis no longer state-persisted (persist: false); clients must callawait controller.initialize()after constructiontokensChainsCache:<chainId>), parallel load on init, debounced persistence of changed chains only, and robust error handlingclearingTokenListData()is async and removes per-chain files; state updated accordingly@metamask/storage-servicedependency and tsconfig references; updates changelogWritten by Cursor Bugbot for commit 3da918d. This will update automatically on new commits. Configure here.